Ce tutorial fait suite à mobile-05a-crud et ajoute une fonctionnalité de recherche progressive à l'application DeckManager.
À la fin du codelab CRUD, l'application affiche la liste complète des decks sans aucun filtre. Dès que la liste grandit, retrouver un deck devient fastidieux. Ce tutorial introduit la recherche par étapes successives.

À travers 4 étapes progressives, apprendre à :
_decks déjà chargé.xaml.csAjouter un champ de recherche qui filtre les decks dont le nom commence par le texte saisi.
Ajouter une Entry de recherche entre le header (ajout de deck) et le CollectionView. Le Grid existant passe de 3 lignes à 4 :
<Grid RowDefinitions="Auto,Auto,*,Auto" Padding="10">
<!-- Header with add button (row 0 - unchanged) -->
<HorizontalStackLayout Grid.Row="0"
Spacing="10"
Margin="0,0,0,10">
<Entry x:Name="NewDeckEntry"
Placeholder="Nom du nouveau deck"
HorizontalOptions="FillAndExpand" />
<Button Text="Ajouter"
Clicked="OnAddDeckClicked"
BackgroundColor="#2196F3"
TextColor="White" />
</HorizontalStackLayout>
<!-- Search bar (row 1 - new) -->
<Entry Grid.Row="1"
x:Name="SearchEntry"
Placeholder="🔍 Rechercher un deck..."
TextChanged="OnSearchTextChanged"
Margin="0,0,0,10" />
<!-- Decks list (row 2 - was row 1) -->
<CollectionView Grid.Row="2"
x:Name="DecksCollectionView"
SelectionMode="None">
<!-- ... DataTemplate inchangé ... -->
</CollectionView>
<!-- Info bar (row 3 - was row 2) -->
<Label Grid.Row="3"
x:Name="InfoLabel"
Text="Prêt"
FontSize="12"
TextColor="Gray"
HorizontalOptions="Center"
Margin="0,10,0,0" />
</Grid>
Ajouter une liste filtrée et le gestionnaire d'événement TextChanged :
public partial class DecksPage : ContentPage
{
private JsonDataService _dataService;
private List<Deck> _decks;
private List<Deck> _filteredDecks; // new: displayed subset
private int _nextId = 1;
public DecksPage()
{
InitializeComponent();
_dataService = new JsonDataService();
_decks = new List<Deck>();
_filteredDecks = new List<Deck>();
LoadDecks();
}
private async void LoadDecks()
{
_decks = await _dataService.LoadDecksAsync();
if (_decks.Any())
{
_nextId = _decks.Max(d => d.Id) + 1;
}
ApplyFilter();
UpdateInfo($"Chargé: {_decks.Count} deck(s)");
}
// Replace RefreshView() with ApplyFilter()
private void ApplyFilter()
{
string search = SearchEntry?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(search))
{
_filteredDecks = _decks;
}
else
{
_filteredDecks = _decks
.Where(d => d.Name.StartsWith(search, StringComparison.OrdinalIgnoreCase))
.ToList();
}
DecksCollectionView.ItemsSource = null;
DecksCollectionView.ItemsSource = _filteredDecks;
}
private void OnSearchTextChanged(object sender, TextChangedEventArgs e)
{
ApplyFilter();
UpdateInfo($"Filtre: '{e.NewTextValue}' → {_filteredDecks.Count} résultat(s)");
}
}
Mettre à jour les appels existants à RefreshView() pour qu'ils appellent ApplyFilter() à la place :
private async void OnAddDeckClicked(object sender, EventArgs e)
{
// ... code inchangé ...
_decks.Add(newDeck);
await _dataService.SaveDecksAsync(_decks);
ApplyFilter(); // was RefreshView()
NewDeckEntry.Text = string.Empty;
UpdateInfo($"Ajouté: {name}");
}
Lancer l'application et taper "ang" dans le champ de recherche. Seuls les decks dont le nom commence par "ang" (ex: "Anglais niveau 1") apparaissent. Vider le champ affiche tous les decks.
Permettre de trouver un deck en tapant n'importe quel fragment du nom, pas seulement le début.
| ✅ "Anglais" | ✅ "Anglais" |
| ❌ | ✅ "Anglais" |
| ❌ | ✅ "Anglais niveau 1" |
Remplacer StartsWith par Contains :
private void ApplyFilter()
{
string search = SearchEntry?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(search))
{
_filteredDecks = _decks;
}
else
{
_filteredDecks = _decks
.Where(d => d.Name.Contains(search, StringComparison.OrdinalIgnoreCase))
.ToList();
}
DecksCollectionView.ItemsSource = null;
DecksCollectionView.ItemsSource = _filteredDecks;
}
*En SQL, LIKE '%texte%' est la recherche par contenu. On peut aussi imiter la syntaxe du joker * pour donner plus de contrôle :
private bool MatchesWildcard(string name, string pattern)
{
// Without wildcard, behaves like Contains
if (!pattern.Contains('*'))
{
return name.Contains(pattern, StringComparison.OrdinalIgnoreCase);
}
// Split on * and check each part in order
string[] parts = pattern.Split('*', StringSplitOptions.RemoveEmptyEntries);
int index = 0;
foreach (string part in parts)
{
int found = name.IndexOf(part, index, StringComparison.OrdinalIgnoreCase);
if (found < 0) return false;
index = found + part.Length;
}
return true;
}
private void ApplyFilter()
{
string search = SearchEntry?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(search))
{
_filteredDecks = _decks;
}
else
{
_filteredDecks = _decks
.Where(d => MatchesWildcard(d.Name, search))
.ToList();
}
DecksCollectionView.ItemsSource = null;
DecksCollectionView.ItemsSource = _filteredDecks;
}
| Tous les decks contenant "niveau" |
| "Anglais niveau 1" (commence par "ang", contient "1") |
| Tous les decks contenant "math" |
Permettre de chercher avec plusieurs mots séparés par des espaces (mode ET) ou par un | (mode OU).
"anglais math" → deck contenant "anglais" ET "math" dans le nom"anglais|math" → deck contenant "anglais" OU "math" dans le nom"anglais" → comportement inchangé (un seul terme)private void ApplyFilter()
{
string search = SearchEntry?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(search))
{
_filteredDecks = _decks;
}
else if (search.Contains('|'))
{
// OR mode: split on | and keep decks matching at least one term
string[] orTerms = search.Split('|', StringSplitOptions.RemoveEmptyEntries);
_filteredDecks = _decks
.Where(d => orTerms.Any(term =>
d.Name.Contains(term.Trim(), StringComparison.OrdinalIgnoreCase)))
.ToList();
}
else
{
// AND mode: split on spaces and keep decks matching all terms
string[] andTerms = search.Split(' ', StringSplitOptions.RemoveEmptyEntries);
_filteredDecks = _decks
.Where(d => andTerms.All(term =>
d.Name.Contains(term, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
DecksCollectionView.ItemsSource = null;
DecksCollectionView.ItemsSource = _filteredDecks;
}
Indiquer le mode de recherche actif :
private void OnSearchTextChanged(object sender, TextChangedEventArgs e)
{
ApplyFilter();
string mode = string.Empty;
string text = e.NewTextValue ?? string.Empty;
if (text.Contains('|'))
mode = " [OU]";
else if (text.Contains(' '))
mode = " [ET]";
UpdateInfo($"Filtre{mode}: '{text}' → {_filteredDecks.Count} résultat(s)");
}
Préparer au moins 4 decks de test en mémoire :
// Test data to add temporarily in LoadDecks() for quick testing
private void LoadTestData()
{
_decks = new List<Deck>
{
new Deck { Id = 1, Name = "Anglais niveau 1", CardCount = 20 },
new Deck { Id = 2, Name = "Anglais niveau 2", CardCount = 15 },
new Deck { Id = 3, Name = "Mathématiques", CardCount = 30 },
new Deck { Id = 4, Name = "Anglais vocabulaire", CardCount = 25 },
new Deck { Id = 5, Name = "Histoire-Géo", CardCount = 40 },
};
_nextId = 6;
ApplyFilter();
}
| — | decks 1, 2, 4 |
| ET | decks 1, 2 (contiennent "anglais" ET "niveau") |
| ET | deck 2 uniquement |
| OU | decks 3, 5 (contiennent "math" OU "histoire") |
Afficher une liste déroulante de suggestions sous le champ de recherche au fur et à mesure de la saisie, et remplir automatiquement le champ au clic.
L'interface se compose de deux couches superposées :
SearchEntry) — toujours visible en hautSuggestionsFrame) — apparaît juste en dessous du champ pendant la saisie, par-dessus la liste principaleDecksCollectionView) — filtrée en temps réel, partiellement couverte quand les suggestions sont visiblesEncapsuler la barre de recherche et les suggestions dans un Grid superposé. Le panel de suggestions doit flotter au-dessus de la liste principale grâce à ZIndex :
<Grid RowDefinitions="Auto,Auto,*,Auto" Padding="10">
<!-- Header (row 0 - unchanged) -->
<HorizontalStackLayout Grid.Row="0" Spacing="10" Margin="0,0,0,10">
<Entry x:Name="NewDeckEntry"
Placeholder="Nom du nouveau deck"
HorizontalOptions="FillAndExpand" />
<Button Text="Ajouter"
Clicked="OnAddDeckClicked"
BackgroundColor="#2196F3"
TextColor="White" />
</HorizontalStackLayout>
<!-- Search + suggestions overlay (row 1) -->
<Grid Grid.Row="1" RowDefinitions="Auto,Auto" Margin="0,0,0,10">
<!-- Search Entry -->
<Entry Grid.Row="0"
x:Name="SearchEntry"
Placeholder="🔍 Rechercher un deck..."
TextChanged="OnSearchTextChanged"
Focused="OnSearchFocused"
Unfocused="OnSearchUnfocused" />
<!-- Autocomplete suggestions (hidden by default) -->
<Frame Grid.Row="1"
x:Name="SuggestionsFrame"
IsVisible="False"
Padding="0"
BorderColor="LightGray"
CornerRadius="4"
HasShadow="True"
ZIndex="10">
<CollectionView x:Name="SuggestionsView"
MaximumHeightRequest="200"
SelectionMode="Single"
SelectionChanged="OnSuggestionSelected">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid Padding="12,8" BackgroundColor="White">
<Label Text="{Binding Name}"
FontSize="16" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Frame>
</Grid>
<!-- Decks list (row 2) -->
<CollectionView Grid.Row="2"
x:Name="DecksCollectionView"
SelectionMode="None">
<!-- DataTemplate inchangé -->
</CollectionView>
<!-- Info bar (row 3) -->
<Label Grid.Row="3"
x:Name="InfoLabel"
Text="Prêt"
FontSize="12"
TextColor="Gray"
HorizontalOptions="Center"
Margin="0,10,0,0" />
</Grid>
Ajouter la logique des suggestions :
// --- Autocomplete logic ---
private void OnSearchFocused(object sender, FocusEventArgs e)
{
UpdateSuggestions(SearchEntry.Text);
}
private void OnSearchUnfocused(object sender, FocusEventArgs e)
{
// Small delay so tap on suggestion registers before hiding
Task.Delay(200).ContinueWith(_ =>
MainThread.BeginInvokeOnMainThread(() =>
SuggestionsFrame.IsVisible = false));
}
private void UpdateSuggestions(string? input)
{
string search = input?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(search) || search.Length < 2)
{
SuggestionsFrame.IsVisible = false;
return;
}
// Suggest deck names that contain the search text
List<Deck> suggestions = _decks
.Where(d => d.Name.Contains(search, StringComparison.OrdinalIgnoreCase))
.Take(5)
.ToList();
if (suggestions.Count == 0)
{
SuggestionsFrame.IsVisible = false;
return;
}
SuggestionsView.ItemsSource = suggestions;
SuggestionsFrame.IsVisible = true;
}
private void OnSuggestionSelected(object sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is Deck selected)
{
// Fill the search box with the selected deck name
SearchEntry.Text = selected.Name;
SuggestionsFrame.IsVisible = false;
SuggestionsView.SelectedItem = null;
ApplyFilter();
}
}
Mettre à jour OnSearchTextChanged pour appeler aussi UpdateSuggestions :
private void OnSearchTextChanged(object sender, TextChangedEventArgs e)
{
ApplyFilter();
UpdateSuggestions(e.NewTextValue);
string mode = string.Empty;
string text = e.NewTextValue ?? string.Empty;
if (text.Contains('|'))
mode = " [OU]";
else if (text.Contains(' '))
mode = " [ET]";
UpdateInfo($"Filtre{mode}: '{text}' → {_filteredDecks.Count} résultat(s)");
}
"ang" → les suggestions "Anglais niveau 1", "Anglais niveau 2", "Anglais vocabulaire" apparaissentIdentifier un comportement inattendu de l'autocomplétion en situation réelle, comprendre pourquoi il se produit, puis le corriger.
Reproduire exactement ces étapes dans l'application :
anglais dans le champ de recherche → la liste se filtre sur les decks "Anglais"| puis commencer à taper esp → le champ contient anglais|esp, mode OU actifQue se passe-t-il ? Observer attentivement le contenu du champ de recherche et la liste affichée après la sélection.
Ouvrir MainPage.xaml.cs et retrouver la méthode OnSuggestionSelected. Répondre aux questions suivantes en lisant le code :
SearchEntry.Text juste avant la ligne SearchEntry.Text = selected ?selected à ce moment-là ?| ?Piste : La méthode reçoit le nom complet du deck sélectionné et l'assigne directement au champ. Elle ne tient aucun compte de ce qui était déjà écrit.
Comparer maintenant avec UpdateSuggestions : cette méthode cherche des suggestions sur lastTerm (le dernier terme saisi), mais OnSuggestionSelected ignore complètement cette distinction.
L'objectif est qu'après la sélection d'une suggestion, le champ de recherche conserve les termes déjà saisis avant le dernier séparateur (| ou espace), et remplace uniquement le dernier terme par le nom sélectionné.
Quelques questions pour guider la réflexion :
string permettent de trouver la position du dernier caractère | ou ?Ajouter un bouton pour réinitialiser le filtre en un clic :
<!-- In the search Grid, replace the Entry with a HorizontalStackLayout -->
<HorizontalStackLayout Grid.Row="0" Spacing="5">
<Entry x:Name="SearchEntry"
Placeholder="🔍 Rechercher..."
TextChanged="OnSearchTextChanged"
Focused="OnSearchFocused"
Unfocused="OnSearchUnfocused"
HorizontalOptions="FillAndExpand" />
<Button Text="✕"
Clicked="OnClearSearchClicked"
BackgroundColor="Transparent"
TextColor="Gray"
FontSize="18"
WidthRequest="40" />
</HorizontalStackLayout>
private void OnClearSearchClicked(object sender, EventArgs e)
{
SearchEntry.Text = string.Empty;
SuggestionsFrame.IsVisible = false;
ApplyFilter();
UpdateInfo("Filtre effacé");
}
Afficher le nombre de résultats directement sous la barre de recherche :
<Label x:Name="ResultCountLabel"
Grid.Row="1"
Text=""
FontSize="12"
TextColor="Gray"
Margin="2,0,0,4" />
private void ApplyFilter()
{
// ... filtering logic ...
ResultCountLabel.Text = _filteredDecks.Count == _decks.Count
? string.Empty
: $"{_filteredDecks.Count} résultat(s) sur {_decks.Count}";
}
Étendre la recherche pour inclure le nombre de cartes (pour retrouver les gros decks) :
_filteredDecks = _decks
.Where(d =>
d.Name.Contains(search, StringComparison.OrdinalIgnoreCase) ||
d.CardCount.ToString().Contains(search))
.ToList();
Offrir un tri alphabétique ou par nombre de cartes :
// After filtering, sort results
_filteredDecks = _filteredDecks
.OrderBy(d => d.Name)
.ToList();
1 | Début du mot |
|
|
2 | N'importe où |
|
|
2+ | Joker |
|
|
3 ET | Tous les mots |
|
|
3 OU | Au moins un mot |
|
|
4 | Autocomplétion |
| suggestions en live |
TextChanged : réagir à chaque frappe dans un EntryFocused / Unfocused : détecter l'activation/désactivation d'un champZIndex : superposer un contrôle par-dessus un autreMaximumHeightRequest : limiter la hauteur d'un contrôle dynamiqueTask.Delay + MainThread : différer une action sur le thread UIWhere, Any, All, Take, OrderByUtilisateur tape
↓
OnSearchTextChanged
├── ApplyFilter() → met à jour DecksCollectionView
└── UpdateSuggestions() → affiche/masque SuggestionsFrame
Utilisateur choisit une suggestion
↓
OnSuggestionSelected
├── SearchEntry.Text = selected.Name
└── ApplyFilter() → filtre sur le nom choisi